/*
 * Copyright 2017 ~ 2025 the original author or authors. James Wong <jameswong1376@gmail.com>>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.wl4g.infra.data.mybatis.loader;

import static com.wl4g.infra.common.lang.Assert2.isTrue;
import static com.wl4g.infra.common.lang.Assert2.notNull;
import static com.wl4g.infra.common.lang.Assert2.state;
import static com.wl4g.infra.common.log.SmartLoggerFactory.getLogger;
import static com.wl4g.infra.common.reflect.ReflectionUtils2.findField;
import static com.wl4g.infra.common.reflect.ReflectionUtils2.getField;
import static com.wl4g.infra.common.reflect.ReflectionUtils2.makeAccessible;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
import static java.lang.System.out;
import static java.lang.Thread.sleep;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.apache.commons.lang3.StringUtils.replace;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

import com.wl4g.infra.common.log.SmartLogger;

/**
 * Mybatis {@link SqlSessionFactory} developments hotspot mapper re-loader.
 * 
 * @author Wangl.sir James Wong <jameswong1376@gmail.com>>
 * @version v1.0 2019年11月14日
 * @since
 */
public final class SqlSessionMapperHotspotLoader implements ApplicationRunner {
    protected final SmartLogger log = getLogger(getClass());

    /** Refresh configuration properties. */
    private final HotspotLoaderProperties config;
    /** Monitor objectives for {@link SqlSessionFactory} */
    private final SqlSessionFactoryBean sessionFactory;

    /** Refresher of last timestamp. */
    private final AtomicLong lastRefreshTime = new AtomicLong(0L);
    /** Runner thread boss. */
    private Thread boss;

    /** Mybatis sql sessions configurations. */
    private Configuration configuration;
    private Resource[] mapperLocations;

    public SqlSessionMapperHotspotLoader(SqlSessionFactoryBean sessionFactory, HotspotLoaderProperties config) {
        notNull(sessionFactory, "SqlSessionFactory can't is null.");
        notNull(config, "MapperHotspotLoader properties config can't is null.");
        this.sessionFactory = sessionFactory;
        this.config = config;
        try {
            // Init configuration.
            init();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    /**
     * Note: Start and run the refresh processing method (note that if web.xml
     * configures spring and springmvc as two containers, it will be called
     * twice, resulting in clear mybatis Sqlelements error.)
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        boss = new Thread(() -> {
            boolean stopped = false;
            while (!stopped && !boss.isInterrupted()) {
                try {
                    sleep(config.getMonitorLoaderIntervalMs());
                    if (isChanged()) {
                        refresh(configuration);
                        stopped = false;
                    }
                } catch (Exception e) {
                    if (config.isFastFail()) {
                        stopped = true;
                        log.error("", e);
                    }
                }
            }
            log.warn("Stopped SqlSession mappers hotspot loader monitor!");
        });
        boss.start();

        log.info("Started SqlSession mappers hotspot loader of {}", sessionFactory.toString());
    }

    /**
     * Initializing configuration、mapperLocations etc.
     * 
     * @throws Exception
     */
    private synchronized void init() throws Exception {
        state(isNull(configuration) && isNull(mapperLocations),
                String.format("Already initialized mappers hotspot loader. configuration for: %s", configuration));
        // Obtain configuration.
        configuration = sessionFactory.getObject().getConfiguration();
        // Obtain mapperLocations.
        Field mapperLocaionsField = findField(SqlSessionFactoryBean.class, "mapperLocations", Resource[].class);
        makeAccessible(mapperLocaionsField);
        mapperLocations = (Resource[]) getField(mapperLocaionsField, sessionFactory);

        // Convert to origin resources.
        mapperLocations = getOriginResources(mapperLocations);

        notNull(configuration, "SqlSessionFactory configuration can't is null.");
        notNull(mapperLocations, "SqlSessionFactory mapperLocations can't is null.");
    }

    /**
     * Refresh the contents of mybatis mapping files.
     * 
     * @param configuration
     * @throws Exception
     */
    private synchronized void refresh(Configuration configuration) throws Exception {
        // 清理Mybatis的所有映射文件缓存, 目前由于未找到清空被修改文件的缓存的key值, 暂时仅支持全部清理, 然后全部加载
        doCleanupOlderCacheConfig(configuration);

        long begin = currentTimeMillis();
        for (Resource rs : mapperLocations) {
            try {
                XMLMapperBuilder builder = new XMLMapperBuilder(rs.getInputStream(), configuration, rs.toString(),
                        configuration.getSqlFragments()); // Reload.
                builder.parse();
                log.debug("Refreshed for: {}", rs);
            } catch (IOException e) {
                log.error(format("Failed to refresh mapper for: %s", rs), e);
            }
        }
        long now = currentTimeMillis();
        out.println(format("%s - Refreshed mappers: %s, cost: %sms", new Date(), mapperLocations.length, (now - begin)));

        // Update refresh time.
        lastRefreshTime.set(now);
    }

    /**
     * Detect if at least one mapper file has been updated.
     * 
     * @return
     * @throws IOException
     */
    private boolean isChanged() throws IOException {
        if (lastRefreshTime.get() <= 0) { // Just initialized?
            lastRefreshTime.set(currentTimeMillis());
            return false;
        }
        for (Resource rs : mapperLocations) {
            if (nonNull(rs) && rs.getFile().lastModified() > lastRefreshTime.get()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Clear several importants caches in configuration
     * 
     * @param configuration
     * @throws Exception
     */
    private synchronized void doCleanupOlderCacheConfig(Configuration configuration) throws Exception {
        Class<?> classConfig = configuration.getClass();

        clearMap(classConfig, configuration, "mappedStatements", null);
        clearMap(classConfig, configuration, "caches", null);
        clearMap(classConfig, configuration, "resultMaps", null);
        clearMap(classConfig, configuration, "parameterMaps", null);
        clearMap(classConfig, configuration, "keyGenerators", null);
        clearMap(classConfig, configuration, "sqlFragments", null);

        clearSet(classConfig, configuration, "loadedResources", null);
    }

    /**
     * Clear map cache in configuration.
     * 
     * @param classConfig
     * @param configuration
     * @param fieldName
     * @param clearKey
     * @throws Exception
     */
    @SuppressWarnings("rawtypes")
    private synchronized void clearMap(Class<?> classConfig, Configuration configuration, String fieldName, Object clearKey)
            throws Exception {
        Field field = classConfig.getDeclaredField(fieldName);
        field.setAccessible(true);
        Map mapConfig = (Map) field.get(configuration);
        // (此用于实现只重新加载单个mapper文件的热部署, 但是目前由于未找到清空被修改文件的缓存的key值,
        // 暂无法实现单个mapper热部署)
        // mapConfig.remove(clearKey);
        mapConfig.clear();
    }

    /**
     * Clear set cache in configuration.
     * 
     * @param classConfig
     * @param configuration
     * @param fieldName
     * @param clearKey
     * @throws Exception
     */
    @SuppressWarnings("rawtypes")
    private synchronized void clearSet(Class<?> classConfig, Configuration configuration, String fieldName, Object clearKey)
            throws Exception {
        Field field = classConfig.getDeclaredField(fieldName);
        field.setAccessible(true);
        Set setConfig = (Set) field.get(configuration);
        // (此用于实现只重新加载单个mapper文件的热部署, 但是目前由于未找到清空被修改文件的缓存的key值,
        // 暂无法实现单个mapper热部署)
        // setConfig.remove(clearKey);
        setConfig.clear();
    }

    /**
     * Because idea does not hot update the mapper.xml file in the target
     * directory by default, it can only be converted to the source directory
     * (original file)
     * 
     * @param mapperLocations
     * @return
     * @throws IOException
     */
    private Resource[] getOriginResources(Resource[] mapperLocations) throws IOException {
        List<Resource> res = new ArrayList<>(mapperLocations.length);
        if (nonNull(mapperLocations)) {
            for (Resource r : mapperLocations) {
                // If the mapper XML is in jar, it cannot be loaded (and not
                // necessary).
                if (r.isFile()) {
                    String path = r.getFile().getAbsolutePath();
                    path = replace(path, TARGET_PART_PATH, SRC_PART_PATH);
                    res.add(new FileSystemResource(path));
                }
            }
        }
        return res.toArray(new Resource[] {});
    }

    // /**
    // * 获取需要刷新的文件列表.(此用于实现只重新加载单个mapper文件的热部署, 但是目前由于未找到清空被修改文件的缓存的key值,
    // * 暂无法实现单个mapper热部署)
    // *
    // * @param beforeTime
    // * 上次刷新时间
    // * @return 刷新文件列表
    // * @throws IOException
    // */
    // private List<Resource> getRefreshResource(Long beforeTime) throws
    // IOException {
    // List<Resource> refreshResourcelist = new ArrayList<Resource>();
    // for (Resource resource : mapperLocations) {
    // if (resource != null && resource.getFile().lastModified() > beforeTime) {
    // refreshResourcelist.add(resource);
    // }
    // }
    // return refreshResourcelist;
    // }

    /**
     * Mybatis mappers hotspot loader properties configuration.
     * 
     * @author Wangl.sir James Wong <jameswong1376@gmail.com>>
     * @version v1.0 2019年11月14日
     * @since
     */
    public static class HotspotLoaderProperties implements Serializable {
        private static final long serialVersionUID = -1L;

        /** {@link SqlSessionFactory} watching intervalMs. */
        private long monitorLoaderIntervalMs = 1000L;

        /** Refresh failed processing policy. */
        private boolean fastFail = false;

        public long getMonitorLoaderIntervalMs() {
            return monitorLoaderIntervalMs;
        }

        public void setMonitorLoaderIntervalMs(long monitorIntervalMs) {
            isTrue(monitorIntervalMs >= 200, "Monitor intervalMs must >=200");
            this.monitorLoaderIntervalMs = monitorIntervalMs;
        }

        public boolean isFastFail() {
            return fastFail;
        }

        public void setFastFail(boolean fastFail) {
            this.fastFail = fastFail;
        }

    }

    private static final String TARGET_PART_PATH = "target" + File.separator + "classes";
    private static final String SRC_PART_PATH = "src" + File.separator + "main" + File.separator + "resources";

}